<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>一维码识别工具</title>
    <style>
      :root {
        --bg:#0b1020; --text:#e5e7eb; --muted:#a3a9b6; --primary:#5b8cff;
        --card-bg: rgba(18, 26, 44, 0.7); --card-border:#1f2a3b;
        --ok:#10b981; --fail:#ef4444; --pill:#2a3a5b; --accent:#7aa4ff;
      }
      body { margin:0; color:var(--text); font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background:
        radial-gradient(800px 300px at 10% 0%, #12203a 0%, #0b1020 60%),
        radial-gradient(800px 300px at 90% 100%, #0e1a34 0%, #0b1020 60%);
      }
      .wrap { max-width: 980px; margin: 40px auto; padding: 0 16px; }
      .card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 16px; padding: 22px; box-shadow: 0 24px 60px rgba(0,0,0,.35); backdrop-filter: saturate(1.2) blur(8px); }
      h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: .2px; }
      .desc { color: var(--muted); margin-bottom: 16px; font-size: 14px; }
      .row { display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
      .hint { min-height: 20px; color: var(--muted); }

      .btn { display:inline-flex; align-items:center; gap:8px; border: 1px solid #26354f; background: #16223d; color: #cfe0ff; padding: 10px 16px; border-radius: 12px; cursor: pointer; font-weight: 600; box-shadow: 0 8px 20px rgba(0,0,0,.2); transition: all .18s ease; }
      .btn:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(0,0,0,.28); border-color:#2b3f63; }
      .btn:active { transform: translateY(0px); }
      .btn:disabled { opacity: .6; cursor: not-allowed; }
      .btn-primary { background: linear-gradient(180deg, var(--primary), #3b6fe5); border-color: #406bd3; color: white; }
      .btn-primary:hover { filter: brightness(1.05); }

      .hidden-file { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); border:0; }

      .preview { margin-top: 12px; display:grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
      .thumb { position:relative; border:1px solid #26354f; border-radius: 12px; overflow:hidden; background:#0e162a; }
      .thumb img { width:100%; height:auto; display:block; }
      /* 长图容器：高度自适应并可滚动，保持原始宽高比 */
      .thumb.long { max-height: 72vh; overflow-y: auto; }
      .thumb.long img { width:100%; height:auto; object-fit: contain; }
      /* 普通缩略图：方形裁剪，提升网格整齐度 */
      .thumb.square img { aspect-ratio: 1 / 1; object-fit: cover; }
      .thumb-caption { position:absolute; left:8px; right:8px; bottom:8px; background: rgba(0,0,0,.35); color:#cfe0ff; padding:4px 6px; border-radius: 8px; font-size: 11px; backdrop-filter: blur(2px); }

      .results { margin-top: 18px; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; position: relative; }
      .file-card { border: 1px solid #253047; border-radius: 14px; padding: 14px; display:flex; flex-direction: column; gap: 10px; background: rgba(13, 19, 34, .6); }
      .file-header { display:flex; justify-content: space-between; align-items:center; }
      .file-name { font-weight: 700; font-size: 14px; }
      .file-status { font-size: 12px; color: var(--muted); }
      .file-status.ok { color: var(--ok); }
      .file-status.fail { color: var(--fail); }
      .barcode-list { display:flex; flex-direction: column; gap: 8px; }
      .barcode-item { background: #0d1322; border: 1px solid #243049; border-radius: 12px; padding: 12px; }
      .barcode-pill { display:inline-block; background: var(--pill); color:#cfe0ff; padding: 2px 8px; border-radius: 999px; font-size: 12px; margin-bottom: 8px; }
      .barcode-data { font-size: 18px; font-weight: 700; letter-spacing: .3px; word-break: break-all; }
      .empty { border: 1px dashed #243049; border-radius: 12px; padding: 18px; color: var(--muted); text-align: center; }

      .loading-overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; backdrop-filter: blur(0px); }
      .loading-overlay.show { display:flex; }
      .spinner { width: 24px; height: 24px; border: 3px solid #325081; border-top-color: #cfe0ff; border-radius: 50%; animation:spin .9s linear infinite; }
      @keyframes spin { to { transform: rotate(360deg); } }

      .footer { margin-top: 14px; color: var(--muted); font-size: 12px; }
      .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
      .dropzone { margin-top: 10px; border: 2px dashed #2b3f63; border-radius: 16px; padding: 16px; text-align: center; color: var(--muted); background: rgba(14, 22, 42, .35); transition: border-color .18s ease, background .18s ease; }
      .dropzone.dragover { border-color: var(--accent); background: rgba(14, 22, 42, .55); color: #cfe0ff; }
      .drop-hint { font-size: 12px; margin-top: 6px; color: var(--muted); }
      .copy-btn { display:inline-flex; align-items:center; gap:6px; border: 1px solid #2b3f63; background: #15223f; color: #cfe0ff; padding: 6px 10px; border-radius: 999px; cursor: pointer; font-size: 12px; }
      .copy-btn:hover { border-color:#3b5a8f; }
      @media (max-width: 520px) {
        .wrap { margin: 24px auto; }
        h1 { font-size: 22px; }
        .results { grid-template-columns: 1fr; }
        .thumb.long { max-height: 58vh; }
      }
    </style>
  </head>
  <body>
    <div class="wrap">
      <div class="card">
        <h1>一维码识别工具</h1>
        <div class="desc">选择 PNG/JPG 图片并点击识别，显示每张图片的一维码结果与图片信息。</div>
        <div class="row">
          <label for="fileInput" class="btn">
            <span>选择图片</span>
          </label>
          <input id="fileInput" class="hidden-file" type="file" accept="image/png,image/jpeg" multiple />
          <button id="btn" type="button" class="btn btn-primary">开始识别</button>
          <span id="hint" class="hint mono"></span>
        </div>
        <div id="dropzone" class="dropzone">
          将图片拖拽到此区域或点击“选择图片”
          <div class="drop-hint">支持 PNG / JPG，多张图片可一次识别 · 支持粘贴截图</div>
        </div>
        <div id="preview" class="preview"></div>
        <div id="results" class="results">
          <div id="loading" class="loading-overlay"><div class="spinner"></div></div>
        </div>
        
      </div>
    </div>

    <script>
      // 常量与全局变量：统一格式与大小限制（前端与后端兼容）
      const ACCEPT_TYPES = ['image/png', 'image/jpeg'];
      const MAX_SIZE_MB = 10;
      const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
      // 全局选择文件集合（来自选择/拖拽/粘贴）
      let selectedFiles = [];

      /**
       * 函数说明：设置列表区域的加载状态覆盖层。
       * @param {boolean} on - true显示加载覆盖层，false隐藏
       */
      function setLoading(on) {
        const overlay = document.getElementById('loading');
        if (!overlay) return;
        overlay.classList.toggle('show', !!on);
      }

      /**
       * 函数说明：渲染上传图片的预览缩略图。
       * 行为：展示等比裁剪的方形预览，保留文件名；避免大图对性能影响。
       * @param {FileList|File[]} files - 用户选择的文件列表
       */
      function renderPreview(files) {
        const box = document.getElementById('preview');
        box.innerHTML = '';
        if (!files || files.length === 0) return;
        const imgs = [];
        for (const f of files) {
          if (!f.type || !f.type.startsWith('image/')) continue;
          const url = URL.createObjectURL(f);
          const item = document.createElement('div');
          // 默认方形缩略图，长图加载后切换为.long以支持滚动与完整显示
          item.className = 'thumb square';
          const img = document.createElement('img');
          img.src = url;
          img.alt = f.name;
          img.onload = () => URL.revokeObjectURL(url);
          const cap = document.createElement('div');
          cap.className = 'thumb-caption mono';
          // 显示文件名与分辨率/大小（KB）
          cap.textContent = f.name;
          img.addEventListener('load', () => {
            const kb = Math.round((f.size || 0) / 1024);
            cap.textContent = `${f.name} · ${img.naturalWidth}×${img.naturalHeight} · ${kb}KB`;
            // 根据长宽比判断是否为“长图”，长图切换容器样式以完整显示
            const ratio = img.naturalHeight / Math.max(1, img.naturalWidth);
            if (ratio > 1.4) {
              item.classList.remove('square');
              item.classList.add('long');
            }
          });
          item.appendChild(img);
          item.appendChild(cap);
          imgs.push(item);
        }
        if (imgs.length) {
          imgs.forEach(el => box.appendChild(el));
        }
      }

      /**
       * 函数说明：对文件列表进行去重，避免同一图片被重复加入（例如剪贴板同时提供items与files）。
       * 修正：移除 lastModified 参与去重，使用 type + size + name(忽略大小写与空白) 组合键，
       * 以适配剪贴板生成的文件在不同来源里 lastModified 不一致的情况。
       * @param {File[]} files - 原始文件列表
       * @returns {File[]} 去重后的文件列表
       */
      function uniqueFiles(files) {
        const seen = new Set();
        const out = [];
        for (const f of files) {
          const name = (f.name || 'image').trim().toLowerCase();
          const type = (f.type || '').trim().toLowerCase();
          const size = f.size || 0;
          const key = `${type}|${size}|${name}`;
          if (seen.has(key)) continue;
          seen.add(key);
          out.push(f);
        }
        return out;
      }

      /**
       * 函数说明：必要时将图片文件转换为PNG以兼容后端（尽量保留原图质量）。
       * 逻辑：当类型非ACCEPT_TYPES但为image/*时，通过Canvas绘制并导出为image/png。
       * 返回：Promise<File|null> 转换后的文件（若无需转换则返回原文件），失败返回null。
       */
      async function convertIfNeeded(file) {
        if (ACCEPT_TYPES.includes(file.type)) return file;
        if (!file.type || !file.type.startsWith('image/')) return null;
        const url = URL.createObjectURL(file);
        try {
          const img = await new Promise((resolve, reject) => {
            const i = new Image();
            i.onload = () => resolve(i);
            i.onerror = reject;
            i.src = url;
          });
          const canvas = document.createElement('canvas');
          canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0);
          const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
          if (!blob) return null;
          const name = (file.name || 'image').replace(/\.[^.]+$/, '') + '.png';
          return new File([blob], name, { type: 'image/png' });
        } finally {
          URL.revokeObjectURL(url);
        }
      }

      /**
       * 函数说明：统一处理输入的文件集合（选择、拖拽、粘贴），应用格式与大小限制，并更新预览。
       * @param {File[]} files - 原始文件集合
       * @param {string} source - 来源描述（用于提示），如“选择/拖拽/粘贴”
       */
      async function processInputFiles(files, source) {
        const hint = document.getElementById('hint');
        const accepted = [];
        let rejected = 0;
        for (const f of files) {
          if (!f.type || !f.type.startsWith('image/')) { rejected++; continue; }
          if (f.size > MAX_SIZE_BYTES) { rejected++; continue; }
          const finalFile = await convertIfNeeded(f);
          if (!finalFile) { rejected++; continue; }
          if (finalFile.size > MAX_SIZE_BYTES) { rejected++; continue; }
          accepted.push(finalFile);
        }
        const uniq = uniqueFiles(accepted);
        if (uniq.length) {
          selectedFiles = uniq;
          renderPreview(selectedFiles);
          hint.textContent = `已${source} ${uniq.length} 个文件${rejected ? ` · 忽略 ${rejected}` : ''}`;
        } else {
          hint.textContent = rejected ? `格式或大小不支持，已忽略 ${rejected} 个项目` : `未检测到可用图片内容`;
        }
      }

      /**
       * 函数说明：渲染空状态提示。（无文件或无识别结果时）
       * 行为：清空结果区域并显示统一的空提示卡片。
       */
      function renderEmptyState() {
        const box = document.getElementById('results');
        box.innerHTML = '<div class="empty">暂无识别内容，请选择图片并点击“开始识别”。</div>';
      }

      /**
       * 函数说明：将识别响应中的解析内容渲染到结果区域。
       * 仅展示文件名与识别出的条码内容；隐藏所有原始JSON数据。
       * @param {Object} resp - DecodeResponse 响应对象
       */
      function renderResults(resp) {
        const box = document.getElementById('results');
        box.innerHTML = '';
        const list = resp.results || [];
        let hasAnyBarcode = false;
        list.forEach((it) => {
          const statusOk = it.status === 'success';
          const card = document.createElement('div');
          card.className = 'file-card';
          const barcodes = Array.isArray(it.barcodes) ? it.barcodes : [];
          if (barcodes.length > 0) hasAnyBarcode = true;
          const headerHtml = `
            <div class="file-header">
              <div class="file-name mono">${it.image?.filename || '未命名'}</div>
              <div class="file-status ${statusOk ? 'ok' : 'fail'}">${statusOk ? '识别完成' : '识别失败'}</div>
            </div>`;
          const listHtml = barcodes.length > 0
            ? `<div class="barcode-list">${barcodes.map(b => `
                  <div class="barcode-item" data-text="${(b.data || '').replace(/"/g, '&quot;')}">
                    <span class="barcode-pill">${b.type || '条码'}</span>
                    <div class="barcode-data">${b.data || ''}</div>
                    <button class="copy-btn" type="button">复制</button>
                  </div>
                `).join('')}</div>`
            : `<div class="empty">未识别到条码</div>`;
          card.innerHTML = headerHtml + listHtml;
          box.appendChild(card);
        });
        if (!list.length || !hasAnyBarcode) {
          // 若整体没有任何条码，显示全局空提示，增强直观性
          if (!box.childElementCount) renderEmptyState();
        }
      }

      /**
       * 函数说明：向后端提交图片并获取识别结果。
       * - 构造FormData，将所选文件以字段名files追加。
       * - 调用后端接口并展示识别结果或错误信息。
       */
      async function submitFiles() {
        const btn = document.getElementById('btn');
        const hint = document.getElementById('hint');
        const fallback = Array.from(document.getElementById('fileInput').files || []);
        const files = selectedFiles && selectedFiles.length ? selectedFiles : fallback;
        if (!files || files.length === 0) {
          hint.textContent = '请先选择图片文件';
          return;
        }
        btn.disabled = true;
        hint.textContent = '识别中...';
        setLoading(true);
        try {
          const fd = new FormData();
          for (const f of files) fd.append('files', f);
          const resp = await fetch('/api/v1/barcode/decode', { method: 'POST', body: fd });
          const data = await resp.json();
          renderResults(data);
          hint.textContent = '识别完成';
        } catch (e) {
          hint.textContent = '请求失败：' + e;
          renderEmptyState();
        } finally {
          btn.disabled = false;
          setLoading(false);
        }
      }

      // 绑定事件
      document.getElementById('btn').addEventListener('click', submitFiles);
      // 绑定文件选择事件以渲染预览
      document.getElementById('fileInput').addEventListener('change', async (e) => {
        const files = Array.from(e.target.files || []);
        await processInputFiles(files, '选择');
      });
      // 拖拽上传事件绑定
      (function bindDropzone(){
        const dz = document.getElementById('dropzone');
        if (!dz) return;
        ['dragenter','dragover'].forEach(ev => {
          dz.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add('dragover'); });
        });
        ['dragleave','drop'].forEach(ev => {
          dz.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove('dragover'); });
        });
        dz.addEventListener('drop', async (e) => {
          const files = Array.from(e.dataTransfer?.files || []);
          await processInputFiles(files, '拖拽');
        });
        // 粘贴图片支持（截图或图片文件），并避免同一图片被重复添加
        dz.addEventListener('paste', async (e) => {
          e.preventDefault(); e.stopPropagation();
          const items = Array.from(e.clipboardData?.items || []);
          const fromItems = items
            .filter(it => it.type && it.type.startsWith('image/'))
            .map(it => it.getAsFile())
            .filter(Boolean);
          const fromFiles = Array.from(e.clipboardData?.files || []);
          let files = fromItems.length ? fromItems : fromFiles;
          files = uniqueFiles(files);
          if (!files.length) {
            const hint = document.getElementById('hint');
            hint.textContent = '剪贴板内容不支持，请粘贴图片或截图';
            return;
          }
          await processInputFiles(files, '粘贴');
        });
      })();
      // 结果区域复制按钮事件委托
      (function bindCopy(){
        // 函数说明：为识别结果区域的“复制”按钮绑定事件，优先使用浏览器剪贴板API；
        // 在WebView环境（EdgeChromium/WebView2）下若受权限或安全上下文限制，回退到 pywebview 提供的 JS Bridge。
        const box = document.getElementById('results');
        if (!box) return;
        box.addEventListener('click', async (e) => {
          const btn = e.target.closest('.copy-btn');
          if (!btn) return;
          const item = btn.closest('.barcode-item');
          const text = item?.getAttribute('data-text') || item?.querySelector('.barcode-data')?.textContent || '';
          const fallbackCopy = () => {
            const ta = document.createElement('textarea');
            ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
          };
          try {
            // 1) 浏览器原生API（需安全上下文与用户手势）
            if (navigator.clipboard && navigator.clipboard.writeText) {
              await navigator.clipboard.writeText(text);
            // 2) WebView 回退：调用 pywebview JS Bridge
            } else if (window.pywebview && window.pywebview.api && window.pywebview.api.copy_text) {
              const ok = await window.pywebview.api.copy_text(text);
              if (!ok) throw new Error('pywebview copy_text 返回失败');
            // 3) 传统回退：textarea + execCommand
            } else {
              fallbackCopy();
            }
            btn.textContent = '已复制';
            setTimeout(() => { btn.textContent = '复制'; }, 1200);
          } catch (err) {
            try {
              // 若原生或pywebview失败，尝试传统回退
              fallbackCopy();
              btn.textContent = '已复制';
              setTimeout(() => { btn.textContent = '复制'; }, 1200);
            } catch (_) {
              btn.textContent = '失败';
              setTimeout(() => { btn.textContent = '复制'; }, 1200);
            }
          }
        });
      })();
      // 初始化空状态
      renderEmptyState();
    </script>
  </body>
  </html>